/*
 * Copyright (C) 2010-2011 Comcast Cable.  All Rights Reserved.
 */
package com.hercules.android.receiver.connection.http;

import com.hercules.android.receiver.AndroidReceiver;
import com.hercules.android.receiver.connection.BaseServerConnection;
import com.hercules.android.receiver.connection.ConnectionUtil;
import com.hercules.android.receiver.connection.ServerConnectionException;
import com.hercules.android.receiver.connection.parser.CommandParsingException;
import com.hercules.android.receiver.connection.parser.EventEncodingException;
import com.hercules.android.receiver.model.commands.XREConnectCommand;
import com.hercules.android.receiver.model.events.XREConnectEvent;
import com.hercules.android.receiver.model.events.XREEvent;

import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.URL;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * <p>
 * This class is a concrete implementation of ServerConnection interface. It extends from BaseServerConnection.
 * </p>
 * <p>
 * Connections between receivers and server will be created using http protocol. It uses java.util.logging package to
 * perform logging and MessageParser from "Android Receiver Parser" component to parse commands and encode events.
 * </p>
 * <p>
 * Sample Usage:
 *
 * <pre>
 * // create HTTPServerConnection instance
 * HTTPServerConnection conn = new HTTPServerConnection();
 *
 * // set fields using the setters
 * conn.setExceptionHandler(new DefaultExceptionHandler());
 * conn.setParser(new DefaultMessageParser());
 * conn.setReconnectAttempts(5);
 * conn.setReconnectAttemptsInterval(1000);
 * conn.setReconnectWaitInterval(1000);
 * conn.setEmptyMessagesInterval(2000);
 *
 * // create connect event
 * XREConnectEvent connectEvent = new XREConnectEvent();
 * // set the fields using setters
 * connectEvent.setApplicationName(&quot;app1&quot;);
 * connectEvent.setName(&quot;XREConnectEvent1&quot;);
 *
 * // create Android receiver
 * AndroidReceiver receiver = new AndroidReceiver();
 * receiver.setApplicationConnectData(new ApplicationConnectData());
 * receiver.getApplicationConnectData().setUrl(&quot;xre://localhost:8080/MockServer/app1&quot;);
 * receiver.setApplicationData(new ApplicationData());
 * receiver.setCommandHandler(new AggregateXRECommandHandler());
 *
 * // connect some receiver:AndroiReceiver to server
 * conn.connect(connectEvent, receiver);
 * Thread.sleep(10000);
 *
 * // send some event to the server
 * String sessionGUID = receiver.getApplicationData().getSessionGUID();
 * XREEvent event = new XREResourceMeasuredEvent();
 * conn.send(sessionGUID, event);
 *
 * // close receiver connection to server
 * conn.close(sessionGUID);
 * </pre>
 *
 * </p>
 * <p>
 * <strong> Thread safety:</strong> Technically it is not thread safe since unpredictable results may occur if send and
 * close methods are called at the same time. Also it is mutable. However it is expected that first, setters will be
 * called to set the fields, next connect method will be called once, next send method will be called any number of
 * times and, at the end, the close method will be called. With these assumptions the class can be used in a thread safe
 * manner.
 * </p>
 *
 * @author DanLazar, morehappiness
 * @version 1.0
 */
public class HTTPServerConnection extends BaseServerConnection<Object> {
    /**
     * <p>
     * Represents the interval between sending empty messages to the server.
     * </p>
     * <p>
     * Can be set using the setter. It has a getter.
     * </p>
     * <p>
     * Used in TCPConnectionHandler.run method.
     * </p>
     * <p>
     * Must be > 0. Time unit: milliseconds. Default is 100 Milliseconds.
     * </p>
     */
    private int emptyMessagesInterval = 100;

    /**
     * <p>
     * Represents the mapping between session guids and http url connections.
     * </p>
     * <p>
     * Will be set in constructor to an empty map.
     * </p>
     * <p>
     * Will be used in HTTPConnectionHandler.run() and send() methods.
     * </p>
     * <p>
     * Keys cannot be null/empty and values cannot be null. The reference is final but the contents can be modified in
     * HTTPConnectionHandler.run() method.
     * </p>
     */
    private final Map<String, HttpURLConnection> httpConnections;

    /**
     * Creates a new instance.
     */
    public HTTPServerConnection() {
        this.httpConnections = new HashMap<String, HttpURLConnection>();
    }

    /**
     * This method connects the given receiver to a server. Should be called only once by each receiver, at startup.
     *
     * @param onConnectEvent the connection event
     * @param receiver the receiver for which connection is created
     * @throws IllegalArgumentException if either parameter is null
     */
    public void connect(XREConnectEvent onConnectEvent, AndroidReceiver receiver) {
        String methodName = "HTTPServerConnection.connect";
        ConnectionUtil.logEntrance(getLogger(), methodName, new String[]{"onConnectEvent", "receiver"}, new Object[]{
            onConnectEvent, receiver});

        ConnectionUtil.checkNull(getLogger(), onConnectEvent, "onConnectEvent", null);
        ConnectionUtil.checkNull(getLogger(), receiver, "receiver", null);

        new HTTPConnectionHandler(onConnectEvent, receiver).start();

        ConnectionUtil.logExit(getLogger(), methodName);
    }

    /**
     * This method will send the event to a server using the receiver connection identified by the given sessionGUID.
     *
     * @param receiver the android receiver to use
     * @param event the event that will be sent to the server
     * @throws IllegalArgumentException if any parameter is null or sessionGUID is empty string
     * @throws ServerConnectionException if any errors occurred while sending the event
     */
    public void send(XREEvent event, AndroidReceiver receiver) throws ServerConnectionException {
        String methodName = "HTTPServerConnection.send";
        ConnectionUtil.logEntrance(getLogger(), methodName, new String[]{"event", "receiver"}, new Object[]{event, receiver});

        ConnectionUtil.checkNull(getLogger(), receiver, "receiver", null);
        ConnectionUtil.checkNull(getLogger(), event, "event", null);

        String sessionGUID = receiver.getApplicationData().getSessionGUID();
        HttpURLConnection connection = null;
        PrintWriter pw = null;
        try {
            // get the connection from httpConnections using the sessionGUID as key
            connection = this.httpConnections.get(sessionGUID);

            if (null == connection) {
                throw ConnectionUtil.logException(getLogger(), new ServerConnectionException("No connection is found for "
                    + sessionGUID));
            }

            connection = this.createConnection(connection.getURL());
            String eventMsg = getParser().encodeEvent(event);
            pw = new PrintWriter(connection.getOutputStream());
            // send event
            ConnectionUtil.send(eventMsg, pw);
        } catch (IOException e) {
            throw ConnectionUtil.logException(getLogger(), new ServerConnectionException(
                "I/O Exception occurred when sending event to the server", e));
        } catch (EventEncodingException e) {
            throw ConnectionUtil.logException(getLogger(), new ServerConnectionException("Can not encode the XRE event", e));
        } finally {
            ConnectionUtil.close(pw);
            this.disconnectConnection(connection);
        }

        ConnectionUtil.logExit(getLogger(), methodName);
    }

    /**
     * Closes the connection for the receiver identified by the given sessionGUID.
     *
     * @param sessionGUID the session identifier
     * @throws IllegalArgumentException if sessionGUID is null/empty string
     */
    public void close(String sessionGUID) {
        String methodName = "HTTPServerConnection.close";
        ConnectionUtil.logEntrance(getLogger(), methodName, new String[]{"sessionGUID"}, new Object[]{sessionGUID});

        ConnectionUtil.checkNullOrEmpty(getLogger(), sessionGUID, "sessionGUID");

        ConnectionUtil.addSessionGUID(getClosedConnections(), sessionGUID);

        ConnectionUtil.logExit(getLogger(), methodName);
    }

    /**
     * Getter for the emptyMessagesInterval field.
     *
     * @return the emptyMessagesInterval field value
     */
    public int getEmptyMessagesInterval() {
        return this.emptyMessagesInterval;
    }

    /**
     * Setter for the emptyMessagesInterval field.
     *
     * @param emptyMessagesInterval the emptyMessageInterval field value to set
     * @throws IllegalArgumentException if emptyMessageInterval <= 0
     */
    public void setEmptyMessagesInterval(int emptyMessagesInterval) {
        ConnectionUtil.checkInt(emptyMessagesInterval, 1, "emptyMessagesInterval");
        this.emptyMessagesInterval = emptyMessagesInterval;
    }

    /**
     * Creates a HttpURLConnection object from the given url.
     *
     * @param url the url
     * @return the created connection
     * @throws MalformedURLException if the url is not valid
     * @throws ProtocolException if the quest method 'GET' can not be set
     * @throws IOException if any other IO exception occurs
     */
    private HttpURLConnection createConnection(URL url) throws IOException {
        // create http url connection
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setDoOutput(true);
        conn.connect();

        return conn;
    }

    /**
     * Disconnects the http url connection.
     *
     * @param connection the http url connection to disconnect
     */
    private void disconnectConnection(HttpURLConnection connection) {
        if (null != connection) {
            connection.disconnect();
        }
    }

    /**
     * <p>
     * This class implements the Thread class and it is an inner class of HTTPServerConnection class.
     *</p>
     * <p>
     * It is used to create a connection to a server for a receiver and to read the commands that are sent from the
     * server using http as the communication protocol.
     * </p>
     * <p>
     * <strong> Thread safety:</strong> It is thread safe since it is immutable.
     * </p>
     *
     * @author DanLazar, morehappiness
     * @version 1.0
     */
    private class HTTPConnectionHandler extends Thread {
        /**
         * <p>
         * Represents the connection event.
         * </p>
         * <p>
         * Will be set in constructor and never changed.
         * </p>
         * <p>
         * Used in run() method.
         * </p>
         * <p>
         * Cannot be null.
         * </p>
         */
        private final XREEvent onConnectEvent;

        /**
         * <p>
         * Represents the receiver for which the connection will be created.
         * </p>
         * <p>
         * Will be set in constructor and never changed.
         * </p>
         * <p>
         * Used in run() method.
         * </p>
         * <p>
         * Cannot be null.
         * </p>
         */
        private final AndroidReceiver receiver;

        /**
         * Creates a new instance setting the fields with namesake parameters.
         *
         * @param onConnectEvent the connection event
         * @param receiver the receiver for which the connection is created.
         */
        public HTTPConnectionHandler(XREEvent onConnectEvent, AndroidReceiver receiver) {
            this.onConnectEvent = onConnectEvent;
            this.receiver = receiver;
        }

        /**
         * This method will create a connection using a http connection to the server for the given receiver .
         */
        @Override
        public void run() {
            String methodName = "HTTPConnectionHandler.run";
            ConnectionUtil.logEntrance(getLogger(), methodName, null, null);

            HttpURLConnection connection = null;
            PrintWriter pw = null;
            InputStream is = null;

            // used to represents the retry connection
            boolean isRetryConnection = false;

            try {
                // get receiver url
                // create URL object
                URL url = new URL("http://" + this.receiver.getApplicationConnectData().getUrl().substring(6));

                // create http url connection
                connection = createConnection(url);

                try {
                    // get the output stream writer and write the encoded event
                    pw = new PrintWriter(connection.getOutputStream());

                    // encode event
                    // send event
                    ConnectionUtil.send(getParser().encodeEvent(onConnectEvent), pw);

                    // get http connection InputStream
                    is = connection.getInputStream();

                    // read from InputStream in order to get the first command
                    boolean firstCommandReceived = false;

                    while (!firstCommandReceived) {
                        if (is.available() > 0) {
                            // get and handle first xre command
                            ConnectionUtil.handleFirstCommand(readCommands(is).get(0), this.receiver);

                            // add {sessionGUID, connection} to the httpConnections map
                            httpConnections.put(this.receiver.getApplicationData().getSessionGUID(), connection);

                            firstCommandReceived = true;
                        } else {
                            // wait for the response from the server
                            Thread.sleep(100);
                        }
                    }
                } finally {
                    ConnectionUtil.close(pw);
                    ConnectionUtil.close(is);
                    // close the connection
                    disconnectConnection(connection);
                }

                // set it to true for retry connecting when exception occurs
                isRetryConnection = true;
                while (ConnectionUtil.isConnectionNotClosed(getClosedConnections(), this.receiver.getApplicationData()
                    .getSessionGUID())) {
                    try {
                        // send empty message to server
                        connection = createConnection(url);
                        pw = new PrintWriter(connection.getOutputStream());
                        ConnectionUtil.send(" ", pw);

                        is = connection.getInputStream();

                        if (is.available() > 0) {
                            // get xre commands and pass commands to the handler
                            for (XREConnectCommand xreCommand : readCommands(is)) {
                                this.receiver.getCommandHandler().onCommandMessage(xreCommand, receiver);
                            }

                            Thread.sleep(emptyMessagesInterval);
                        }
                    } finally {
                        ConnectionUtil.close(pw);
                        ConnectionUtil.close(is);
                        // close the connection
                        disconnectConnection(connection);
                    }
                }

                // reset to false
                isRetryConnection = false;
                // remove receiver.applicationData.sessionGUID from closedConnections list
                ConnectionUtil.removeSessionGUID(getClosedConnections(), this.receiver.getApplicationData()
                    .getSessionGUID());
                // remove the entry from httpConnections which has receiver.applicationData.sessionGUID as key
                httpConnections.remove(this.receiver.getApplicationData().getSessionGUID());
            } catch (ClassCastException e) {
                handleException(e, isRetryConnection, connection);
            } catch (NumberFormatException e) {
                handleException(e, isRetryConnection, connection);
            } catch (EventEncodingException e) {
                handleException(e, isRetryConnection, connection);
            } catch (CommandParsingException e) {
                handleException(e, isRetryConnection, connection);
            } catch (IOException e) {
                handleException(e, isRetryConnection, connection);
            } catch (InterruptedException e) {
                handleException(e, isRetryConnection, connection);
            } catch (Exception e) {
                // catch the general exception from receiver.commandHandler.onCommandMessage and receiver's methods
                handleException(e, isRetryConnection, connection);
            }

            ConnectionUtil.logExit(getLogger(), methodName);
        }

        /**
         * Checks whether the connection is connected or not. Returns true if the connection is available; otherwise
         * returns false.
         *
         * @param connection the HTTP URL connection to check
         * @return true if the connection is available; otherwise returns false
         */
        private boolean isConnected(HttpURLConnection connection) {
            try {
                connection.getResponseCode();
            } catch (IOException e) {
                return false;
            }

            return true;
        }

        /**
         * This method will read commands from the given InputStream.
         *
         * @param inputStream the InputStream instance
         * @return the XRE connection command
         * @throws IOException if any IO exception occurs
         * @throws NumberFormatException if a string can not parse into a valid integer
         * @throws ClassCastException if the command is not type of XREConnectCommand
         * @throws CommandParsingException if commands string can not parse into XRE command list
         */
        @SuppressWarnings("unchecked")
        private List<XREConnectCommand> readCommands(InputStream inputStream) throws IOException,
            CommandParsingException {
            int availableBytes = inputStream.available();
            int commandIndex = 0;

            StringBuilder commandsString = new StringBuilder("[");

            while (commandIndex < availableBytes) {
                // get first 4 bytes (these represent the command length)
                byte[] commandBytesLength = new byte[4];

                int count = inputStream.read(commandBytesLength, 0, 4);

                int length = Integer.parseInt(new String(commandBytesLength, 0, count));

                byte[] commandBytes = new byte[length];
                commandIndex += 4;
                count = inputStream.read(commandBytes, 0, length);

                commandIndex += length;

                // add "{" to the beginning of command and "}" to the end of command
                commandsString.append("{").append(new String(commandBytes, 0, count)).append("}").append(",");
            }

            // remove the last "," from commandsString (this is the last character)
            commandsString.deleteCharAt(commandsString.length() - 1);
            // add "]" to the commandsString
            commandsString.append("]");

            // parse commands
            List<?> commands = getParser().parseCommands(commandsString.toString());

            return (List<XREConnectCommand>) commands;
        }

        /**
         * Logs and handles the exception. If isRetryConnection is true, retry connection.
         * <p>
         * AndroidAssembly: Removed useless exceptionHandler parameter.
         *
         * @param <T> the exception type
         * @param e the exception
         * @param isRetryConnection whether to retry connection or not
         * @param connection the http url connection
         */
        private <T extends Exception> void handleException(T e, boolean isRetryConnection, HttpURLConnection connection) {
            ConnectionUtil.logAndHandleException(getLogger(), e, getExceptionHandler());
            if (isRetryConnection) {
                try {
                    retryConnect(connection);
                } catch (Exception e1) {
                    // log and handle the exception from receiver.onShutdownCommand in retryConnect
                    ConnectionUtil.logAndHandleException(getLogger(), e1, getExceptionHandler());
                }
            }
        }

        /**
         * Retry connection.
         *
         * @param connection the http url connection
         * @throws Exception from receiver.onShutdownCommand
         */
        private void retryConnect(HttpURLConnection connection) throws Exception {
            // check if the connection is connected and if it is not then reconnect the receiver
            if (!isConnected(connection)) {
                this.receiver.doShutdown(true);
                boolean reconnected = false;

                // retry connection
                for (int i = 0; i < getReconnectAttempts(); i++) {
                    this.receiver.connect(true);

                    Thread.sleep(getReconnectWaitInterval());

                    HttpURLConnection conn = httpConnections.get(receiver.getApplicationData().getSessionGUID());
                    if (null != conn && isConnected(conn)) {
                        reconnected = true;
                        break;
                    }

                    Thread.sleep(getReconnectAttemptsInterval());
                }

                if (!reconnected) {
                    // add receiver.applicationData.sessionGUID in closedConnections list (this will stop the
                    // while loop)
                    ConnectionUtil.addSessionGUID(getClosedConnections(), this.receiver.getApplicationData()
                        .getSessionGUID());
                    this.receiver.doShutdown(false);
                }
            }
        }
    }
}
